---
title: Boundary Studio
---

<Tip>
For 2025 Q1, Boundary Studio is free for new accounts!

Boundary Studio 2 will be released in 2025 Q2 with a new pricing model.
</Tip>

To enable observability with BAML, you'll first need to sign up for a [Boundary Studio](https://app.boundaryml.com) account.

Once you've signed up, you'll be able to create a new project and get your API key.

Then simply add the following environment variable prior to running your application:

```bash
export BOUNDARY_API_KEY=your_api_key_here
```

There you'll be able to see all the metrics and logs from your application including:

- Cost
- Function calls
- Execution time
- Token Usage
- Prompt Logs
- and more...

## Tracing Custom Events


BAML allows you to trace any function with the **@trace** decorator.
This will make the function's input and output show up in the Boundary dashboard. This works for any python function you define yourself. BAML LLM functions (or any other function declared in a .baml file) are already traced by default. Logs are only sent to the Dashboard if you setup your environment variables correctly.

### Example

In the example below, we trace each of the two functions `pre_process_text` and `full_analysis`:

<CodeGroup>
```python Python
from baml_client import baml
from baml_client.types import Book, AuthorInfo
from baml_client.tracing import trace

# You can also add a custom name with trace(name="my_custom_name")
# By default, we use the function's name.
@trace
def pre_process_text(text):
    return text.replace("\n", " ")


@trace
async def full_analysis(book: Book):
    sentiment = await baml.ClassifySentiment(
        pre_process_text(book.content)
    )
    book_analysis = await baml.AnalyzeBook(book)
    return book_analysis


@trace
async def test_book1():
    content = """Before I could reply that he [Gatsby] was my neighbor...
    """
    processed_content = pre_process_text(content)
    return await full_analysis(
        Book(
            title="The Great Gatsby",
            author=AuthorInfo(firstName="F. Scott", lastName="Fitzgerald"),
            content=processed_content,
        ),
    )
```

```typescript TypeScript
import { baml } from 'baml_client';
import { Book, AuthorInfo } from 'baml_client/types';
import { traceSync, traceAsync } from 'baml_client/tracing';

const preProcessText = traceSync('preProcessText', function(text: string): Promise<string> {
    return text.replace(/\n/g, " ");
});

const fullAnalysis = traceAsync('fullAnalysis', async function(book: Book): Promise<any> {
    const sentiment = await baml.ClassifySentiment(
        preProcessText(book.content)
    );
    const bookAnalysis = await baml.AnalyzeBook(book);
    return bookAnalysis;
});

const testBook1 = traceAsync('testBook1', async function(): Promise<any> {
    const content = `Before I could reply that he [Gatsby] was my neighbor...`;
    const processedContent = preProcessText(content);
    return await fullAnalysis(
        new Book(
            "The Great Gatsby",
            new AuthorInfo("F. Scott", "Fitzgerald"),
            processedContent
        )
    );
});
```

```go Go
package main

import (
    "context"
    "fmt"

    b "example.com/baml_client"
)

type AuthorInfo struct {
    FirstName string
    LastName  string
}

func main() {
    ctx := context.Background()

    // BAML functions are automatically traced when using Boundary Studio
    bookSummary, err := b.GenerateBookSummary(
        ctx,
        "The Great Gatsby",
        AuthorInfo{
            FirstName: "F. Scott",
            LastName:  "Fitzgerald",
        },
        "A classic American novel...",
    )
    if err != nil {
        panic(fmt.Sprintf("Failed to generate book summary: %v", err))
    }

    fmt.Printf("Book Summary: %s\n", bookSummary)

    // Note: Tracing non-BAML functions is not yet supported in Go.
    // Custom function tracing will be available in a future release.
    // Please contact us if this feature is needed for your use case.
}
```

```text Ruby
Tracing non-baml functions is not yet supported in Ruby.
```


```text REST (OpenAPI)
Tracing non-baml functions is not yet supported in REST (OpenAPI).
```
</CodeGroup>


This allows us to see each function invocation, as well as all its children in the dashboard:

<img src="/assets/studio/dashboard-test-pic.png" width="auto" />


### Adding custom tags

The dashboard view allows you to see custom tags for each of the function calls. This is useful for adding metadata to your traces and allow you to query your generated logs more easily.

To add a custom tag, you can import **set_tags(..)** as below:

```python
from baml_client.tracing import set_tags, trace
import typing

@trace
async def pre_process_text(text):
    set_tags(userId="1234")

    # You can also create a dictionary and pass it in
    tags_dict: typing.Dict[str, str] = {"userId": "1234"}
    set_tags(**tags_dict) # "**" unpacks the dictionary
    return text.replace("\n", " ")
```

### Tags on BAML calls and retrieving them with the Collector

You can also set tags directly on a BAML function call and then retrieve them from the `Collector`. Tags from a parent trace are inherited by the BAML function call and merged with any function-specific tags you pass.

<Tabs>
<Tab title="Python" language="python">
```python
from baml_client import b
from baml_client.tracing import trace, set_tags
from baml_py import Collector

@trace
async def parent_fn(msg: str):
    # Set tags on the parent trace (these propagate to child BAML calls)
    set_tags(parent_id="p123", run="xyz")

    collector = Collector(name="tags-collector")

    # You can also set per-call tags via baml_options
    await b.TestOpenAIGPT4oMini(
        msg,
        baml_options={
            "collector": collector,
            "tags": {"call_id": "first", "version": "v1"},
        },
    )

    # Retrieve tags from the last function log
    log = collector.last
    assert log is not None
    print(log.tags)  # {"parent_id": "p123", "run": "xyz", "call_id": "first", "version": "v1"}
```
</Tab>

<Tab title="TypeScript" language="typescript">
```typescript
import { b } from "baml_client";
import { Collector } from "@boundaryml/baml";
import { traceAsync, setTags } from "../baml_client/tracing";

const parent = traceAsync("parentTS", async (msg: string) => {
  setTags({ parentId: "p123", run: "xyz" });

  const collector = new Collector("tags-collector");

  await b.TestOpenAIGPT4oMini(msg, {
    collector,
    tags: { callId: "first", version: "v1" },
  });

  const log = collector.last!;
  const tags = log.tags;
  console.log(tags); // { parentId: "p123", run: "xyz", callId: "first", version: "v1" }
});

await parent("hi");
```
</Tab>

<Tab title="Go" language="go">
```go
package main

import (
    "context"
    "fmt"
    b "example.com/integ-tests/baml_client"
)

func run() error {
    ctx := context.Background()

    collector, err := b.NewCollector("tags-collector")
    if err != nil {
        return err
    }

    // Set per-call tags using WithTags
    tags := map[string]string{
        "callId":  "first",
        "version": "v1",
    }
    _, err = b.TestOpenAIGPT4oMini(ctx, "hello", b.WithCollector(collector), b.WithTags(tags))
    if err != nil {
        return err
    }

    logs, err := collector.Logs()
    if err != nil {
        return err
    }
    if len(logs) > 0 {
        t, err := logs[0].Tags()
        if err != nil {
            return err
        }
        fmt.Printf("Tags: %+v\n", t)
    }
    return nil
}
```
</Tab>
</Tabs>

Notes:
- Tags from `set_tags`/`setTags` on a parent `trace` are merged into the BAML function's tags.
- Per-call tags are provided via `baml_options` in Python and the options object in TypeScript; in Go use `b.WithTags(map[string]string)`.
- Retrieve tags from a `FunctionLog` using `log.tags` (Python/TypeScript) or `log.Tags()` (Go).

### Tracing with ThreadPoolExecutor (Python)

When using Python's `concurrent.futures.ThreadPoolExecutor`, traced functions submitted to the thread pool will start with **fresh, independent tracing contexts**. This is by design and differs from async/await execution.

#### Expected Behavior

<CodeGroup>
```python Python
from concurrent.futures import ThreadPoolExecutor
from baml_client.tracing import trace

@trace
def parent_function():
    with ThreadPoolExecutor() as executor:
        # Submit worker to thread pool
        future = executor.submit(worker_function, "data")
        result = future.result()

@trace
def worker_function(data):
    # This will be an independent root trace
    # NOT a child of parent_function
    process_data(data)

@trace
def process_data(data):
    # This WILL be a child of worker_function
    # (same thread execution)
    return data.upper()
```
</CodeGroup>

In the trace hierarchy, you'll see:
- `parent_function` as a root trace (depth 1)
- `worker_function` as an **independent root** trace (depth 1) - not a child
- `process_data` as a child of `worker_function` (depth 2)

#### Why This Happens

Python's `contextvars` (used for tracing context) don't automatically propagate to thread pool threads. Each worker thread starts with a fresh context to:
- Avoid complexity with context sharing across threads
- Prevent potential race conditions
- Maintain clear thread boundaries

#### Best Practices

1. **Use async/await for related work**: If you need to maintain parent-child relationships for parallel execution, use `asyncio` instead of thread pools:

```python
@trace
async def parent_async():
    # These will maintain parent-child relationship
    results = await asyncio.gather(
        async_worker("task1"),
        async_worker("task2")
    )
```

2. **Understand the trace hierarchy**: When debugging, remember that thread pool workers appear as separate root traces in your observability dashboard.

3. **Tags don't propagate**: Tags set in the parent function won't automatically appear in thread pool workers since they have independent contexts.

